
package com.google.p2pbench;

import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothServerSocket;
import android.bluetooth.BluetoothSocket;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Handler;
import android.os.Parcel;
import android.os.ParcelUuid;
import android.os.Parcelable;
import android.util.Log;
import android.util.Pair;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.nio.ByteBuffer;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;

public class BluetoothConnectivity implements Connectivity {
  private static final String TAG = "BluetoothConnectivity";

  private final Context mContext; // Needed to observe BT state changes.
  private BluetoothAdapter mAdapter;
  private boolean mInitiallyOn;
  private BluetoothObserver mBluetoothObserver;
  private final BluetoothDiscoverer mBluetoothDiscoverer = new BluetoothDiscoverer();

  private Listener mListener;
  private final Map<Pair<String, Service>, ServiceBackend> mServices =
      new HashMap<Pair<String, Service>, ServiceBackend>();
  private final Map<Pair<String, Discoverer>, DiscovererBackend> mDiscoverers =
      new HashMap<Pair<String, Discoverer>, DiscovererBackend>();

  private final Handler mHandler = new Handler();

  private class BluetoothObserver extends BroadcastReceiver {
    @Override
    public void onReceive(Context context, Intent intent) {
      mHandler.post(new Runnable() {
        @Override
        public void run() {
          onBluetoothChange();
        }
      });
    }
  }

  private class BluetoothDiscoverer extends BroadcastReceiver {
    private final Set<BluetoothDevice> mDevices = new HashSet<BluetoothDevice>();
    private final Map<UUID, Set<DiscovererBackend>> mDiscoveredUuids =
        new HashMap<UUID, Set<DiscovererBackend>>();
    private boolean mRegistered = false;

    BluetoothDiscoverer() {}

    void register(UUID uuid, DiscovererBackend backend) {
      Log.w(TAG, "discovering " + uuid);
      Set<DiscovererBackend> backends = mDiscoveredUuids.get(uuid);
      if (backends == null) {
        backends = new HashSet<DiscovererBackend>(1);  // Minimal size.
        mDiscoveredUuids.put(uuid, backends);
      }
      backends.add(backend);

      boolean found = false;
      for (BluetoothDevice dev : mDevices) {
        if (dev.getUuids() == null) continue;
        for (ParcelUuid u : dev.getUuids()) {
          if (u.getUuid().equals(uuid)) {
            backend.onDiscovery(dev);
            found = true;
            break;
          }
        }
      }
      if (!found) start();
    }

    void unregister(UUID uuid, DiscovererBackend backend) {
      Set<DiscovererBackend> backends = mDiscoveredUuids.get(uuid);
      if (backends == null) return;
      backends.remove(backend);
      if (backends.isEmpty()) mDiscoveredUuids.remove(uuid);
      if (mDiscoveredUuids.isEmpty()) stop();
    }

    void start() {
      Log.w(TAG, "BluetoothDiscoverer:start");
      if (!mAdapter.isDiscovering()) mAdapter.startDiscovery(); // TODO: check output
      if (!mRegistered) {
        IntentFilter filter = new IntentFilter();
        filter.addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED);
        filter.addAction(BluetoothDevice.ACTION_FOUND);
        filter.addAction(BluetoothDevice.ACTION_UUID);
        mContext.registerReceiver(this, filter);
        mRegistered = true;
      }
    }

    void stop() {
      Log.w(TAG, "BluetoothDiscoverer:stop");
      mAdapter.cancelDiscovery(); // TODO: check output
      if (mRegistered) {
        mContext.unregisterReceiver(this);
        mRegistered = false;
      }
    }

    void foundUuid(BluetoothDevice dev, ParcelUuid uuid) {
      if (uuid == null)
        return;
      Set<DiscovererBackend> backends = mDiscoveredUuids.get(uuid.getUuid());
      if (backends != null) {
        for (DiscovererBackend b : backends)
          b.onDiscovery(dev);
      }
    }

    @Override
    public void onReceive(Context context, Intent intent) {
      if (intent.getAction().equals(BluetoothAdapter.ACTION_DISCOVERY_FINISHED)) {
        for (BluetoothDevice dev : mDevices)
          dev.fetchUuidsWithSdp();
      } else if (intent.getAction().equals(BluetoothDevice.ACTION_FOUND)) {
        BluetoothDevice dev = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
        Log.w(TAG, "ACTION_FOUND " + dev);
        mDevices.add(dev);
        if (dev.getUuids() != null) {
          for (ParcelUuid u : dev.getUuids())
            foundUuid(dev, u);
        }
      } else if (intent.getAction().equals(BluetoothDevice.ACTION_UUID)) {
        BluetoothDevice dev = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
        Parcelable[] uuids = intent.getParcelableArrayExtra(BluetoothDevice.EXTRA_UUID);
        if (uuids == null) return;
        Log.w(TAG, "ACTION_UUID " + uuids.length + " uuids on " + dev);
        for (Parcelable uuid : uuids)
          foundUuid(dev, (ParcelUuid) uuid);
      }
    }
  }

  private void registerObserver() {
    if (mBluetoothObserver != null) return;
    mBluetoothObserver = new BluetoothObserver();
    IntentFilter filter = new IntentFilter();
    filter.addAction(BluetoothAdapter.ACTION_STATE_CHANGED);
    mContext.registerReceiver(mBluetoothObserver, filter);
  }

  private void unregisterObserver() {
    if (mBluetoothObserver == null) return;
    mContext.unregisterReceiver(mBluetoothObserver);
    mBluetoothObserver = null;
  }

  private void onBluetoothChange() {
    if (mAdapter.isEnabled()) {
      if (mListener != null) mListener.onConnected();
    } else if (mAdapter.getState() == BluetoothAdapter.STATE_TURNING_OFF
        || mAdapter.getState() == BluetoothAdapter.STATE_OFF) {
      if (mListener != null) mListener.onError(new ConnectivityException("Bluetooth is off"));
    }
  }

  private static UUID uuidFromService(String serviceType) {
    try {
      return UUID.nameUUIDFromBytes(serviceType.getBytes("US-ASCII"));
    } catch (UnsupportedEncodingException e) {
      // WTF?
      return null;
    }
  }

  // NOTE: BluetoothConnectivity.Peer is cacheable -- remains valid forever.
  private static class PeerImpl implements Peer {
    final BluetoothDevice mDevice;
    final UUID mUuid;

    PeerImpl(BluetoothDevice device, UUID uuid) {
      mDevice = device;
      mUuid = uuid;
    }

    BluetoothSocket createSocket() throws ConnectivityException {
      if (mUuid == null)
        throw new ConnectivityException("Cannot connect to non-service Bluetooth peer");
      try {
        BluetoothSocket sock = mDevice.createInsecureRfcommSocketToServiceRecord(mUuid);
        sock.connect();
        return sock;
      } catch (IOException e) {
        throw new ConnectivityException("Failed to create BluetoothSocket", e);
      }
    }

    @Override
    public int describeContents() {
      return 0;
    }

    @SuppressWarnings("unused")
    public static final Parcelable.Creator<PeerImpl> CREATOR = new Parcelable.Creator<PeerImpl>() {
      @Override
      public PeerImpl createFromParcel(Parcel in) {
        return new PeerImpl(BluetoothDevice.CREATOR.createFromParcel(in),
            ParcelUuid.CREATOR.createFromParcel(in).getUuid());
      }

      @Override
      public PeerImpl[] newArray(int size) {
        return new PeerImpl[size];
      }
    };

    @Override
    public void writeToParcel(Parcel dest, int flags) {
      dest.writeParcelable(mDevice, flags);
      dest.writeParcelable(new ParcelUuid(mUuid), flags);
    }

    @Override
    public String getDescription() {
      return "[Bluetooth " + mDevice + " " + mUuid + "]";
    }

  }

  private static class SocketImpl implements Socket {
    BluetoothSocket mSocket;

    SocketImpl(BluetoothSocket socket) {
      mSocket = socket;
    }

    @Override
    public void close() {
      try {
        mSocket.close();
      } catch (IOException e) {
        // ignore
      }
    }

    @Override
    public boolean isConnected() {
      return mSocket.isConnected();
    }

    @Override
    public InputStream getInputStream() throws ConnectivityException {
      try {
        return mSocket.getInputStream();
      } catch (IOException e) {
        throw new ConnectivityException("BluetoothSocket failed", e);
      }
    }

    @Override
    public OutputStream getOutputStream() throws ConnectivityException {
      try {
        return mSocket.getOutputStream();
      } catch (IOException e) {
        throw new ConnectivityException("BluetoothSocket failed", e);
      }
    }
  }

  private class ServiceBackend {
    final String mServiceType;
    final Service mService;

    BluetoothServerSocket mServerSocket;
    Thread mAcceptorThread;

    Runnable mAcceptor = new Runnable() {
      @Override
      public void run() {
        try {
          while (true) {
            BluetoothSocket socket = mServerSocket.accept();
            mService.onStream(new PeerImpl(socket.getRemoteDevice(), null), new SocketImpl(socket));
          }
        } catch (IOException e) {
          mService.onError(new ConnectivityException("Acceptor failed", e));
          shutdown();
        }
      }
    };

    ServiceBackend(String serviceType, Service service) {
      mServiceType = serviceType;
      mService = service;

      Log.w(TAG, "Listening on " + uuidFromService(mServiceType));
      try {
        mServerSocket = mAdapter.listenUsingInsecureRfcommWithServiceRecord(
            mServiceType, uuidFromService(mServiceType));
      } catch (IOException e) {
        mService.onError(new ConnectivityException("Could not listen", e));
        return;
      }

      mAcceptorThread = new Thread(mAcceptor, mService.toString() + " acceptor");
      mAcceptorThread.start();
    }

    PeerImpl getPeer() {
      return new PeerImpl(
          mAdapter.getRemoteDevice(mAdapter.getAddress()), uuidFromService(mServiceType));
    }

    synchronized void shutdown() {
      if (mServerSocket != null) {
        try {
          mServerSocket.close();
        } catch (IOException e) {
          // ignore
        }
        mServerSocket = null;
      }
    }
  }

  private class DiscovererBackend {
    final String mServiceType;
    final UUID mUuid;
    final Discoverer mDiscoverer;

    DiscovererBackend(String serviceType, Discoverer discoverer) {
      mServiceType = serviceType;
      mDiscoverer = discoverer;
      mUuid = uuidFromService(mServiceType);
      mBluetoothDiscoverer.register(mUuid, this);
    }

    void onDiscovery(BluetoothDevice device) {
      mDiscoverer.onDiscovery(mServiceType, new PeerImpl(device, mUuid));
    }

    void shutdown() {
      mBluetoothDiscoverer.unregister(mUuid, this);
    }
  }

  public BluetoothConnectivity(Context context) {
    mContext = context;
  }

  @Override
  public boolean supportsMessages() {
    return false;
  }

  @Override
  public boolean connect(Listener listener) {
    Util.assertOnThread(mHandler);
    mListener = listener;
    mAdapter = BluetoothAdapter.getDefaultAdapter();
    if (mAdapter == null) return false;

    registerObserver();

    mInitiallyOn = mAdapter.isEnabled();
    if (mInitiallyOn) {
      onBluetoothChange();
      return true;
    }
    return mAdapter.enable();
  }

  @Override
  public void shutdown(int mode) {
    Util.assertOnThread(mHandler);
    if (mAdapter == null) return;
    mBluetoothDiscoverer.stop();
    if (mode == SHUTDOWN_OFF || (mode == SHUTDOWN_PREVIOUS && !mInitiallyOn)) mAdapter.disable();
    unregisterObserver();
    if (mListener != null) mListener.onError(new ConnectivityException("shutdown"));
  }

  @Override
  public Peer getBroadcastPeer() {
    return null;
  }

  @Override
  public void setBroadcastService(Service service) throws ConnectivityException {
    throw new ConnectivityException("Not supported");
  }

  @Override
  public void sendMessage(Peer peer, ByteBuffer msg) throws ConnectivityException {
    throw new ConnectivityException("Not supported");
  }

  @Override
  public Socket getSocket(Peer peer) throws ConnectivityException {
    // Connecting sockets requires discovery to be stopped.
    mAdapter.cancelDiscovery(); // TODO: check output
    PeerImpl peerImpl = (PeerImpl) peer;
    return new SocketImpl(peerImpl.createSocket());
  }

  @Override
  public Peer announce(String serviceType, Service service) throws ConnectivityException {
    Util.assertOnThread(mHandler);

    if (mServices.isEmpty()) {
      try {
        // Work around a bug in ICS.
        Log.w(TAG, "setDiscoverableTimeout workaround");
        BluetoothAdapter.class.getDeclaredMethod("setDiscoverableTimeout", int.class)
            .invoke(mAdapter, 0);
      } catch (Exception e) {
        Log.e(TAG, "sigh", e);
      }
      Intent intent = new Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE);
      intent.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 0 /* infinite */);
      mContext.startActivity(intent);
    }

    ServiceBackend backend = new ServiceBackend(serviceType, service);
    mServices.put(Pair.create(serviceType, service), backend);
    return backend.getPeer();
  }

  @Override
  public void unnannounce(String serviceType, Service service) {
    Util.assertOnThread(mHandler);
    ServiceBackend backend = mServices.remove(Pair.create(serviceType, service));
    if (backend != null) backend.shutdown();
  }

  @Override
  public void discover(String serviceType, Discoverer listener) {
    Util.assertOnThread(mHandler);
    DiscovererBackend backend = new DiscovererBackend(serviceType, listener);
    mDiscoverers.put(Pair.create(serviceType, listener), backend);
  }

  @Override
  public void stopDiscovery(String serviceType, Discoverer listener) {
    Util.assertOnThread(mHandler);
    DiscovererBackend backend = mDiscoverers.remove(Pair.create(serviceType, listener));
    if (backend != null) backend.shutdown();
  }
}
